You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

454 lines
12 KiB

<script setup lang="ts">
definePageMeta({ layout: false });
const route = useRoute();
const router = useRouter();
const { $toast } = useNuxtApp() as any;
const slugParam = computed(() => String(route.params.slug));
const isCreate = computed(() => slugParam.value === "new");
interface ModelOption {
id: number;
name: string;
modelId: string;
providerName?: string;
}
const models = ref<ModelOption[]>([]);
const form = reactive({
slug: "",
name: "",
avatar: "",
description: "",
personality: "",
scenario: "",
firstMessage: "",
exampleDialogue: "",
systemPrompt: "",
defaultModelId: null as number | null,
tagsText: "",
isPublic: 1,
enabled: 1,
sortOrder: 0,
});
const loading = ref(false);
const saving = ref(false);
async function loadModels() {
try {
const res = await $fetch<{ code: number; data: { list: any[] } }>("/api/llm/models");
const list = res.data?.list ?? [];
models.value = list.map((m) => ({
id: m.id,
name: m.name,
modelId: m.modelId,
providerName: m.providerName,
}));
} catch {
models.value = [];
}
}
async function loadCard() {
if (isCreate.value) return;
loading.value = true;
try {
const res = await $fetch<{ code: number; data: any }>(
`/api/admin/characters/${slugParam.value}`,
);
const d = res.data;
form.slug = d.slug ?? "";
form.name = d.name ?? "";
form.avatar = d.avatar ?? "";
form.description = d.description ?? "";
form.personality = d.personality ?? "";
form.scenario = d.scenario ?? "";
form.firstMessage = d.firstMessage ?? "";
form.exampleDialogue = d.exampleDialogue ?? "";
form.systemPrompt = d.systemPrompt ?? "";
form.defaultModelId = d.defaultModelId ?? null;
form.tagsText = Array.isArray(d.tags) ? d.tags.join(",") : "";
form.isPublic = d.isPublic ?? 1;
form.enabled = d.enabled ?? 1;
form.sortOrder = d.sortOrder ?? 0;
} catch (e: any) {
$toast?.error?.(e?.data?.message ?? "加载角色失败");
router.push("/admin/characters");
} finally {
loading.value = false;
}
}
async function handleSave() {
if (!form.slug || !form.name) {
$toast?.error?.("slug、name 为必填");
return;
}
saving.value = true;
try {
const tags = form.tagsText
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const body = {
slug: form.slug,
name: form.name,
avatar: form.avatar || null,
description: form.description || null,
personality: form.personality || null,
scenario: form.scenario || null,
firstMessage: form.firstMessage || null,
exampleDialogue: form.exampleDialogue || null,
systemPrompt: form.systemPrompt || "",
defaultModelId: form.defaultModelId,
tags: JSON.stringify(tags),
isPublic: form.isPublic,
enabled: form.enabled,
sortOrder: form.sortOrder,
};
if (isCreate.value) {
await $fetch("/api/characters", { method: "POST", body });
$toast?.success?.("创建成功");
} else {
await $fetch(`/api/characters/${slugParam.value}`, { method: "PUT", body });
$toast?.success?.("保存成功");
}
router.push("/admin/characters");
} catch (e: any) {
$toast?.error?.(e?.data?.message ?? "保存失败");
} finally {
saving.value = false;
}
}
onMounted(async () => {
await loadModels();
await loadCard();
});
</script>
<template>
<div class="admin-character-edit">
<header class="page-header">
<NuxtLink to="/admin/characters" class="back-link">← 返回列表</NuxtLink>
<h1 class="page-title">{{ isCreate ? "创建角色卡" : "编辑角色卡" }}</h1>
</header>
<div v-if="loading" class="loading-state">加载中...</div>
<template v-else>
<!-- 基本信息 -->
<section class="config-section">
<h2 class="section-title">基本信息</h2>
<div class="form-grid">
<div class="form-field">
<label class="field-label">Slug <span class="required">*</span></label>
<input v-model="form.slug" type="text" class="config-input" placeholder="如 luna" :disabled="!isCreate" />
</div>
<div class="form-field">
<label class="field-label">名称 <span class="required">*</span></label>
<input v-model="form.name" type="text" class="config-input" placeholder="角色名" />
</div>
<div class="form-field form-field-full">
<label class="field-label">头像 URL</label>
<input v-model="form.avatar" type="text" class="config-input" placeholder="https://... 或留空使用首字母" />
</div>
<div class="form-field form-field-full">
<label class="field-label">简介</label>
<input v-model="form.description" type="text" class="config-input" placeholder="一句话介绍这个角色" />
</div>
<div class="form-field form-field-full">
<label class="field-label">标签(逗号分隔)</label>
<input v-model="form.tagsText" type="text" class="config-input" placeholder="治愈, 陪伴, 夜谈" />
</div>
</div>
</section>
<!-- 人设 -->
<section class="config-section">
<h2 class="section-title">人设(上下文自主设计)</h2>
<p class="section-desc">这些字段会在运行时组装成角色的 system prompt</p>
<div class="form-field">
<label class="field-label">性格</label>
<textarea v-model="form.personality" class="config-textarea" rows="3" placeholder="温柔、细腻、略带俏皮…" />
</div>
<div class="form-field">
<label class="field-label">世界观 / 场景</label>
<textarea v-model="form.scenario" class="config-textarea" rows="4" placeholder="角色所处的背景设定…" />
</div>
<div class="form-field">
<label class="field-label">开场白(进入会话时的首条消息)</label>
<textarea v-model="form.firstMessage" class="config-textarea" rows="3" placeholder="角色主动说出的第一句话…" />
</div>
<div class="form-field">
<label class="field-label">对话示例(few-shot,约束口吻与风格)</label>
<textarea v-model="form.exampleDialogue" class="config-textarea" rows="5" placeholder="你:…&#10;角色:…" />
</div>
<div class="form-field">
<label class="field-label">行为规范(底层约束)</label>
<textarea v-model="form.systemPrompt" class="config-textarea" rows="4" placeholder="始终以温柔治愈的语气回应…" />
</div>
</section>
<!-- 模型与行为 -->
<section class="config-section">
<h2 class="section-title">模型与行为</h2>
<div class="form-field">
<label class="field-label">默认模型</label>
<select v-model="form.defaultModelId" class="config-select">
<option :value="null">— 未设置 —</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.modelId }}) — {{ m.providerName || "未知" }}
</option>
</select>
</div>
<div class="form-field" style="margin-top: 16px;">
<label class="field-label">排序</label>
<input v-model.number="form.sortOrder" type="number" class="config-input" style="width: 120px;" />
</div>
<div class="toggle-grid" style="margin-top: 16px;">
<label class="toggle-item">
<input type="checkbox" :checked="form.isPublic === 1" @change="form.isPublic = form.isPublic ? 0 : 1" />
<div class="toggle-info">
<div class="toggle-name">公开</div>
<div class="toggle-desc">是否在角色画廊中展示</div>
</div>
</label>
<label class="toggle-item">
<input type="checkbox" :checked="form.enabled === 1" @change="form.enabled = form.enabled ? 0 : 1" />
<div class="toggle-info">
<div class="toggle-name">启用</div>
<div class="toggle-desc">角色是否可用</div>
</div>
</label>
</div>
</section>
<div class="action-bar">
<NuxtLink to="/admin/characters" class="btn-cancel">取消</NuxtLink>
<button class="save-btn" :disabled="saving" @click="handleSave">
{{ saving ? "保存中..." : isCreate ? "创建" : "保存" }}
</button>
</div>
</template>
</div>
</template>
<style scoped>
.admin-character-edit {
max-width: 800px;
margin: 0 auto;
padding: 32px 24px;
}
.page-header {
margin-bottom: 32px;
}
.back-link {
display: inline-block;
font-size: 13px;
color: var(--color-muted);
text-decoration: none;
margin-bottom: 8px;
}
.back-link:hover {
color: var(--color-primary);
}
.page-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 400;
color: var(--color-ink);
margin: 0;
}
.loading-state {
text-align: center;
padding: 48px;
color: var(--color-muted);
}
.config-section {
margin-bottom: 32px;
padding: 24px;
background: var(--color-surface-card);
border: 1px solid var(--color-hairline);
border-radius: 16px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: var(--color-ink);
margin: 0 0 4px;
}
.section-desc {
font-size: 13px;
color: var(--color-muted);
margin: 0 0 16px;
}
.required {
color: #e53e3e;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.form-field-full {
grid-column: 1 / -1;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--color-muted);
}
.config-input {
padding: 8px 12px;
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 14px;
color: var(--color-ink);
background: var(--color-canvas);
outline: none;
transition: border-color 0.15s;
}
.config-input:focus {
border-color: var(--color-primary);
}
.config-input:disabled {
background: var(--color-surface-soft);
color: var(--color-muted);
cursor: not-allowed;
}
.config-textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--color-hairline);
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
color: var(--color-ink);
background: var(--color-canvas);
resize: vertical;
outline: none;
}
.config-textarea:focus {
border-color: var(--color-primary);
}
.config-select {
padding: 8px 12px;
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 14px;
color: var(--color-ink);
background: var(--color-canvas);
outline: none;
cursor: pointer;
}
.toggle-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.toggle-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
background: var(--color-surface-soft);
border: 1px solid var(--color-hairline);
border-radius: 10px;
cursor: pointer;
}
.toggle-item input[type="checkbox"] {
margin-top: 2px;
accent-color: var(--color-primary);
}
.toggle-info {
flex: 1;
}
.toggle-name {
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
}
.toggle-desc {
font-size: 12px;
color: var(--color-muted);
margin-top: 2px;
}
.action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.btn-cancel {
padding: 8px 20px;
background: var(--color-surface-card);
color: var(--color-body-strong);
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.btn-cancel:hover {
background: var(--color-surface-soft);
}
.save-btn {
padding: 8px 24px;
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.save-btn:hover:not(:disabled) {
background: var(--color-primary-active);
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>